Skip to content

Meta Cloud API Whatsapp Integration#2975

Open
SmittieC wants to merge 30 commits intomainfrom
cs/whatsapp
Open

Meta Cloud API Whatsapp Integration#2975
SmittieC wants to merge 30 commits intomainfrom
cs/whatsapp

Conversation

@SmittieC
Copy link
Contributor

@SmittieC SmittieC commented Mar 5, 2026

Technical Description

Adds support for Meta Cloud API as a new WhatsApp messaging provider, enabling direct integration with the WhatsApp Business Platform without requiring a third-party intermediary like Turn.io.

This is phase 1 of the integration where only text messages are supported. Audio messages are coming in a followup PR.

Key changes:

  • New messaging provider type (meta_cloud_api): Registered as a MessagingProviderType with its own config form (MetaCloudAPIMessagingConfigForm) supporting business_id, access_token, app_secret, and verify_token fields
  • MetaCloudAPIService: New MessagingService implementation that sends text messages via the Meta Graph API (v25.0) and resolves phone number IDs from E.164 numbers using the WhatsApp Business Account Phone Number Management API
  • Webhook endpoint (/channels/whatsapp/meta/incoming_message): Handles both the Meta GET verification handshake and incoming POST message payloads. Verifies X-Hub-Signature-256 HMAC signatures using the configured app_secret
  • Phone number ID resolution: During channel creation, the form validates the phone number against the WhatsApp Business Account and stores the resolved phone_number_id in extra_data. This ID is used for routing incoming webhooks and as the
    from identifier when sending messages
  • WhatsappChannel refactor: Extracted a from_identifier property that returns either phone_number_id (Meta Cloud API) or number (other providers), removing duplicated from_number/to_number logic across send_text_to_user,
    send_voice_to_user, and send_file_to_user
  • Meta Cloud API reuses the Turn.io message format (MetaCloudAPIMessage = TurnWhatsappMessage) since both use the same WhatsApp Business API payload structure

I'll write up some user docs for this as well

Migrations

  • The migrations are backwards compatible

Migration 0043_alter_messagingprovider_type adds meta_cloud_api to the MessagingProviderType choices. This is backwards compatible — existing code will simply not encounter the new type.

Demo

To set up:

  1. Create a Meta Cloud API messaging provider with your WhatsApp Business Account ID, System User Access Token, App Secret, and Webhook Verify Token
  2. Create a WhatsApp channel on an experiment using the new provider
  3. Configure the webhook URL in your Meta App dashboard pointing to /channels/whatsapp/meta/incoming_message
  4. Send a WhatsApp message to the configured business number

Docs and Changelog

  • This PR requires docs/changelog update
Walkthrough guide (AI guide) ## Overview

This PR adds Meta Cloud API as a new WhatsApp messaging provider, enabling direct integration with the WhatsApp Business Platform without a third-party intermediary like Turn.io. Phase 1 supports text messages only (audio coming in a follow-up PR).

Size: Large (781 additions, 27 deletions, 18 files changed)

Key areas: apps/service_providers/ (provider type, config, service), apps/channels/ (webhook, views, forms, tasks), apps/chat/channels.py (WhatsappChannel refactor)

Architecture Impact

  • New provider type plugged into existing MessagingProviderTypeform_clsget_messaging_service dispatch. Clean extension, no existing patterns broken.
  • Single global webhook routed by phone_number_id (unlike Turn.io's per-experiment URLs). New routing pattern in the codebase.
  • Signature verification after channel lookup — deliberate choice since app_secret lives in encrypted provider config.
  • from_identifier property on WhatsappChannel abstracts Meta vs. others, consolidating duplicated from_number logic.

Review Comments Summary

CodeRabbit flagged docstring coverage below 80% threshold. Author addressed review comments across multiple commits. Key iterations: webhook signature verification, verify_token hashing, class-based view refactor, module-level function extraction.


Recommended Reading Order

Step 1 of 8: Provider Type & Migration

Files: apps/service_providers/models.py, apps/service_providers/migrations/0044_alter_messagingprovider_type.py

Why first: Everything else depends on the new enum value and the extra_data field on MessagingProvider.

New enum value:

class MessagingProviderType(models.TextChoices):
    twilio = "twilio", _("Twilio")
    turnio = "turnio", _("Turn.io")
    sureadhere = "sureadhere", _("SureAdhere")
    slack = "slack", _("Slack")
+   meta_cloud_api = "meta_cloud_api", _("Meta Cloud API (WhatsApp)")

Dispatch wiring:

    @property
    def form_cls(self):
        ...
+           case MessagingProviderType.meta_cloud_api:
+               return forms.MetaCloudAPIMessagingConfigForm

    def get_messaging_service(self, config):
        ...
+           case MessagingProviderType.meta_cloud_api:
+               return messaging_service.MetaCloudAPIService(**config)

New field on MessagingProvider:

class MessagingProvider(BaseTeamModel, ProviderMixin):
    type = models.CharField(max_length=255, choices=...)
    name = models.CharField(max_length=255)
    config = encrypt(models.JSONField(default=dict))
+   extra_data = models.JSONField(default=dict, blank=True)

Migration adds the enum choice and the extra_data JSONField. Backwards compatible.

What to notice:

  • extra_data is a plain JSONField (not encrypted) — stores verify_token_hash for efficient DB lookups. Sensitive values stay in encrypted config.
  • Migration is backwards compatible — existing code never encounters the new type.

Architecture: Follows the established provider extension pattern: add enum → wire form_cls → wire get_messaging_service. No new patterns at this layer.


Step 2 of 8: Provider Config Form

File: apps/service_providers/forms.py

Why here: Defines what fields are collected when creating a Meta Cloud API provider, and how the verify token hash gets persisted.

class MetaCloudAPIMessagingConfigForm(ObfuscatingMixin, ProviderTypeConfigForm):
    obfuscate_fields = ["access_token", "app_secret", "verify_token"]

    business_id = forms.CharField(label=_("WhatsApp Business Account ID"))
    access_token = forms.CharField(label=_("System User Access Token"))
    app_secret = forms.CharField(
        label=_("App Secret"),
        help_text=_("Used to verify incoming webhook signatures (X-Hub-Signature-256)."),
    )
    verify_token = forms.CharField(
        label=_("Webhook Verify Token"),
        help_text=_("Token used by Meta to verify the webhook URL. Must match the token configured in your Meta app."),
    )

    def save(self, instance):
        instance = super().save(instance)
        verify_token = self.cleaned_data["verify_token"]
        instance.extra_data = {
            "verify_token_hash": hashlib.sha256(verify_token.encode()).hexdigest(),
        }
        return instance

What to notice:

  • 3 fields obfuscated in UI (access_token, app_secret, verify_token), but business_id is not — just an account identifier.
  • save() hashes verify_token with SHA-256, stores in extra_data. Enables webhook verification endpoint to do a DB lookup by hash.
  • save() overwrites extra_data entirely (not merging). Fine since verify_token_hash is the only extra_data for this provider type.

Step 3 of 8: MetaCloudAPIService

File: apps/service_providers/messaging_service.py

Why here: Core domain logic — how we talk to Meta's Graph API. Phone number resolution and message sending live here.

Class definition:

class MetaCloudAPIService(MessagingService):
    _type: ClassVar[str] = "meta_cloud_api"
    supported_platforms: ClassVar[list] = [ChannelPlatform.WHATSAPP]
    voice_replies_supported: ClassVar[bool] = False
    supported_message_types = [MESSAGE_TYPES.TEXT]

    access_token: str
    business_id: str
    app_secret: str = ""
    verify_token: str = ""
    _phone_number_id: str | None = pydantic.PrivateAttr(default=None)

    META_API_BASE_URL: ClassVar[str] = "https://graph.facebook.com/v25.0"
    META_API_TIMEOUT: ClassVar[int] = 30
    WHATSAPP_CHARACTER_LIMIT: ClassVar[int] = 4096

Phone number resolution:

    def is_valid_number(self, number: str) -> bool:
        self._phone_number_id = self._fetch_phone_number_id(number)
        if not self._phone_number_id:
            raise ValueError(
                f"{number} was not found in the WhatsApp Business Account. "
                "Please verify the number is registered with your business."
            )
        return True

    def get_phone_number_id(self) -> str | None:
        return self._phone_number_id

    def _fetch_phone_number_id(self, phone_number: str) -> str | None:
        url = f"{self.META_API_BASE_URL}/{self.business_id}/phone_numbers"
        response = httpx.get(
            url, headers=self._headers,
            params={"fields": "id,display_phone_number"},
            timeout=self.META_API_TIMEOUT
        )
        response.raise_for_status()
        for entry in response.json().get("data", []):
            display = entry.get("display_phone_number", "")
            try:
                parsed = phonenumbers.parse(display)
                normalized = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
            except phonenumbers.NumberParseException:
                continue
            if normalized == phone_number:
                return entry["id"]
        return None

Sending messages:

    def send_text_message(self, message: str, from_: str, to: str, platform: ChannelPlatform, **kwargs):
        url = f"{self.META_API_BASE_URL}/{from_}/messages"
        chunks = smart_split(message, chars_per_string=self.WHATSAPP_CHARACTER_LIMIT)
        for chunk in chunks:
            data = {
                "messaging_product": "whatsapp",
                "to": to,
                "type": "text",
                "text": {"body": chunk},
            }
            response = httpx.post(url, headers=self._headers, json=data, timeout=self.META_API_TIMEOUT)
            response.raise_for_status()

What to notice:

  • is_valid_number has a side effect: caches resolved phone_number_id in _phone_number_id. The form later calls get_phone_number_id() to retrieve it. Avoids a second API call but couples the two methods.
  • from_ in send_text_message is the phone_number_id, not a phone number. URL is /{phone_number_id}/messages.
  • Message splitting via smart_split at 4096 chars — WhatsApp's per-message limit.
  • Only TEXT in supported_message_types — voice/file support coming in phase 2.
  • _fetch_phone_number_id fetches ALL phone numbers and iterates. Pagination may be needed for accounts with many numbers.

Step 4 of 8: Channel Form & Data Model

Files: apps/channels/forms.py, apps/channels/datamodels.py

Why here: The form is where phone_number_id resolution happens at channel creation. The data model shows message format reuse.

Data model — simple type alias:

# Meta Cloud API uses the same WhatsApp Business API message format as Turn.io
MetaCloudAPIMessage = TurnWhatsappMessage

Form refactor — clean_number split into clean_number (parse) + clean (validate):

 class WhatsappChannelForm(WebhookUrlFormBase):
     def clean_number(self):
         try:
             number_obj = phonenumbers.parse(self.cleaned_data["number"])
-            number = phonenumbers.format_number(number_obj, phonenumbers.PhoneNumberFormat.E164)
-            service = self.messaging_provider.get_messaging_service()
+            return phonenumbers.format_number(number_obj, phonenumbers.PhoneNumberFormat.E164)
+        except phonenumbers.NumberParseException:
+            raise forms.ValidationError("Enter a valid phone number (e.g. +12125552368).") from None
+
+    def clean(self):
+        cleaned_data = super().clean()
+        number = cleaned_data.get("number")
+        if not number or not self.messaging_provider:
+            return cleaned_data
+
+        service = self.messaging_provider.get_messaging_service()
+        try:
             if not service.is_valid_number(number):
                 self.warning_message = (
                     f"{number} was not found at the provider. Please make sure it is there before proceeding"
                 )
-            return number
-        except phonenumbers.NumberParseException:
-            raise forms.ValidationError("Enter a valid phone number (e.g. +12125552368).") from None
+        except ValueError as e:
+            self.add_error("number", str(e))
+            return cleaned_data
+        except Exception:
+            self.add_error("number", "Could not validate this number right now. Please try again.")
+            return cleaned_data
+
+        if self.messaging_provider.type == MessagingProviderType.meta_cloud_api:
+            cleaned_data["phone_number_id"] = service.get_phone_number_id()
+        return cleaned_data

What to notice:

  • Refactoring from clean_number to clean is better Django practice — cross-field validation belongs in clean().
  • Meta's is_valid_number raises ValueError (hard error, no phone_number_id = can't function). Turn.io/Twilio return False (soft warning).
  • phone_number_id flows into cleaned_dataChannelFormWrapper.save()channel.extra_data automatically.
  • Generic Exception catch is a resilience measure — if the Meta API is down, the form gracefully errors.

Step 5 of 8: Webhook Helpers

File: apps/channels/meta_webhook.py (NEW FILE)

Why here: Security-critical building blocks used by the view. Three pure module-level functions.

def extract_message_values(data: dict) -> list[dict]:
    """Extract value dicts that contain messages from Meta webhook payload."""
    values = []
    for entry in data.get("entry", []):
        for change in entry.get("changes", []):
            value = change.get("value", {})
            if value.get("messages") and value.get("metadata", {}).get("phone_number_id"):
                values.append(value)
    return values


def verify_webhook(request) -> HttpResponse:
    """Handle the Meta webhook GET verification handshake."""
    mode = request.GET.get("hub.mode")
    token = request.GET.get("hub.verify_token")
    challenge = request.GET.get("hub.challenge")

    if mode != "subscribe" or not token or not challenge:
        return HttpResponseBadRequest("Verification failed.")

    token_hash = hashlib.sha256(token.encode()).hexdigest()
    exists = MessagingProvider.objects.filter(
        type=MessagingProviderType.meta_cloud_api,
        extra_data__verify_token_hash=token_hash,
    ).exists()

    if exists:
        return HttpResponse(challenge, content_type="text/plain")
    return HttpResponseBadRequest("Verification failed.")


def verify_signature(payload: bytes, signature_header: str, app_secret: str) -> bool:
    """Verify the X-Hub-Signature-256 header from Meta webhooks."""
    if not signature_header.startswith("sha256=") or not app_secret:
        return False

    expected_signature = signature_header.removeprefix("sha256=")
    computed = hmac.new(
        app_secret.encode(),
        payload,
        hashlib.sha256,
    ).hexdigest()
    return constant_time_compare(computed, expected_signature)

What to notice:

  • Hash-based DB lookup for verify_token — avoids iterating all providers. Smart use of Django JSONField lookups.
  • constant_time_compare prevents timing attacks on HMAC. Good security practice.
  • extract_message_values filters for entries with both messages AND phone_number_id — skips delivery receipts (statuses) that come through the same webhook.
  • All three are module-level functions — clean, testable, no state.

Step 6 of 8: Webhook View & URL Routing

Files: apps/channels/views.py, apps/channels/urls.py, apps/channels/models.py

Why here: HTTP integration layer that ties everything together.

URL registration:

path("whatsapp/meta/incoming_message",
     views.MetaCloudAPIWebhookView.as_view(),
     name="new_meta_cloud_api_message"),

Webhook URL property on ExperimentChannel:

        elif provider_type == MessagingProviderType.meta_cloud_api:
            uri = reverse("channels:new_meta_cloud_api_message")

The view:

@method_decorator(waf_allow(WafRule.NoUserAgent_HEADER), name="dispatch")
@method_decorator(csrf_exempt, name="dispatch")
class MetaCloudAPIWebhookView(View):
    def get(self, request):
        log.debug("Meta Cloud API webhook verification request received")
        return meta_webhook.verify_webhook(request)

    def post(self, request):
        try:
            data = json.loads(request.body)
        except json.JSONDecodeError:
            log.debug("Meta Cloud API webhook received invalid JSON")
            return HttpResponseBadRequest("Invalid JSON.")

        if data.get("object") != "whatsapp_business_account":
            log.debug("Meta Cloud API webhook ignored: object=%s", data.get("object"))
            return HttpResponse()

        try:
            message_values = meta_webhook.extract_message_values(data)
            if not message_values:
                log.debug("Meta Cloud API webhook received payload with no messages")
                return HttpResponse()

            first_phone_number_id = message_values[0]["metadata"]["phone_number_id"]
        except (KeyError, IndexError):
            log.debug("Meta Cloud API webhook payload missing expected fields")
            return HttpResponse()

        channel = (
            ExperimentChannel.objects.filter(
                platform=ChannelPlatform.WHATSAPP,
                extra_data__phone_number_id=first_phone_number_id,
                messaging_provider__type=MessagingProviderType.meta_cloud_api,
            )
            .select_related("experiment", "team", "messaging_provider")
            .first()
        )
        if not channel:
            log.info("Meta Cloud API webhook: no channel found for incoming payload")
            return HttpResponse()

        # Signature verification happens after channel lookup because the app_secret
        # needed to verify is stored in the channel's messaging provider config.
        app_secret = channel.messaging_provider.config.get("app_secret", "")
        signature = request.headers.get("X-Hub-Signature-256", "")
        if not meta_webhook.verify_signature(request.body, signature, app_secret):
            log.warning("Meta Cloud API webhook signature verification failed for channel %s", channel.id)
            return HttpResponse()

        set_current_team(channel.team)
        request.experiment = channel.experiment

        log.debug("Meta Cloud API webhook dispatching %d message(s) for channel %s", len(message_values), channel.id)
        for value in message_values:
            tasks.handle_meta_cloud_api_message.delay(
                channel_id=channel.id,
                team_slug=channel.team.slug,
                message_data=value,
            )

        return HttpResponse()

What to notice:

  • Single global endpoint — no experiment ID in URL (unlike Turn.io). Routing by phone_number_id in payload.
  • Signature verification after channel lookup — explicitly documented. app_secret is in encrypted provider config so we need the channel first.
  • All error paths return 200 — Meta retries on non-2xx and can disable webhooks. This is Meta's documented best practice.
  • csrf_exempt + waf_allow(NoUserAgent_HEADER) — Meta webhooks have no CSRF tokens or standard user-agent headers.
  • Good query optimization with select_related.

Architecture: New routing pattern. Turn.io uses per-experiment URLs (/turn/<experiment_id>/). Meta uses a single endpoint with payload-based routing. Both coexist cleanly.


Step 7 of 8: WhatsappChannel Refactor & Task

Files: apps/chat/channels.py, apps/channels/tasks.py

Why here: Where message processing happens after webhook dispatches. The from_identifier refactor is the key DRY improvement.

New from_identifier property:

    @property
    def from_identifier(self) -> str:
        """Returns the phone number ID for Meta Cloud API, or the phone number for other providers."""
        extra_data = self.experiment_channel.extra_data
        if self.experiment_channel.messaging_provider.type == MessagingProviderType.meta_cloud_api:
            phone_number_id = extra_data.get("phone_number_id")
            if not phone_number_id:
                raise ValueError("Meta Cloud API channel is missing phone_number_id in extra_data")
            return phone_number_id
        return extra_data["number"]

Simplified send methods (same pattern for all three):

     def send_text_to_user(self, text: str):
-        from_number = self.experiment_channel.extra_data["number"]
-        to_number = self.participant_identifier
         self.messaging_service.send_text_message(
-            message=text, from_=from_number, to=to_number, platform=ChannelPlatform.WHATSAPP
+            message=text, from_=self.from_identifier, to=self.participant_identifier, platform=ChannelPlatform.WHATSAPP
         )

     def send_voice_to_user(self, synthetic_voice: SynthesizedAudio):
-        from_number = self.experiment_channel.extra_data["number"]
-        to_number = self.participant_identifier
         self.messaging_service.send_voice_message(
-            synthetic_voice, from_=from_number, to=to_number, platform=ChannelPlatform.WHATSAPP
+            synthetic_voice, from_=self.from_identifier, to=self.participant_identifier, platform=ChannelPlatform.WHATSAPP,
         )

     def send_file_to_user(self, file: File):
-        from_number = self.experiment_channel.extra_data["number"]
-        to_number = self.participant_identifier
         self.messaging_service.send_file_to_user(
-            from_=from_number, to=to_number,
+            from_=self.from_identifier, to=self.participant_identifier,
             ...
         )

New Celery task:

@shared_task(bind=True, base=TaskbadgerTask, ignore_result=True)
def handle_meta_cloud_api_message(self, channel_id: int, team_slug: str, message_data: dict):
    message = MetaCloudAPIMessage.parse(message_data)
    experiment_channel = (
        ExperimentChannel.objects.filter(
            id=channel_id,
            experiment__is_archived=False,
        )
        .select_related("experiment", "team", "messaging_provider")
        .first()
    )
    if not experiment_channel:
        log.info("No experiment channel found for channel_id=%s team=%s", channel_id, team_slug)
        return
    channel = WhatsappChannel(experiment_channel.experiment.default_version, experiment_channel)
    update_taskbadger_data(self, channel, message)
    channel.new_user_message(message)

Also added messaging_provider to select_related in shared get_experiment_channel:

-    return query.select_related("experiment", "team").first()
+    return query.select_related("experiment", "team", "messaging_provider").first()

What to notice:

  • from_identifier eliminates duplication across 3 send methods. Future providers benefit from this abstraction too.
  • Task takes channel_id directly — view already resolved the channel, avoiding redundant queries.
  • Adding messaging_provider to select_related in get_experiment_channel benefits all providers.

Step 8 of 8: Tests

Files: apps/channels/tests/test_meta_cloud_api_webhook.py (new, 242 lines), apps/channels/tests/test_forms.py, apps/service_providers/tests/test_messaging_providers.py

Webhook test fixtures:

APP_SECRET = "test_app_secret"
VERIFY_TOKEN = "test_verify_token"

def _make_signature(payload: bytes, secret: str = APP_SECRET) -> str:
    sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return f"sha256={sig}"

@pytest.fixture()
def meta_cloud_api_provider():
    return MessagingProviderFactory.create(
        type=MessagingProviderType.meta_cloud_api,
        config={
            "access_token": "test_token",
            "business_id": "biz_123",
            "app_secret": APP_SECRET,
            "verify_token": VERIFY_TOKEN,
        },
        extra_data={
            "verify_token_hash": hashlib.sha256(VERIFY_TOKEN.encode()).hexdigest(),
        },
    )

@pytest.fixture()
def meta_cloud_api_channel(meta_cloud_api_provider):
    return ExperimentChannelFactory.create(
        platform=ChannelPlatform.WHATSAPP,
        messaging_provider=meta_cloud_api_provider,
        experiment__team=meta_cloud_api_provider.team,
        extra_data={"number": "+15551234567", "phone_number_id": "12345"},
    )

Signature verification tests:

class TestMetaCloudAPIWebhookVerifySignature:
    def test_valid_signature(self):
        payload = b'{"test": "data"}'
        signature = _make_signature(payload)
        assert meta_webhook.verify_signature(payload, signature, APP_SECRET) is True

    def test_invalid_signature(self):
        assert meta_webhook.verify_signature(b'{"test": "data"}', "sha256=invalid", APP_SECRET) is False

    def test_missing_sha256_prefix(self):
        payload = b'{"test": "data"}'
        sig = hmac.new(APP_SECRET.encode(), payload, hashlib.sha256).hexdigest()
        assert meta_webhook.verify_signature(payload, sig, APP_SECRET) is False

    def test_empty_app_secret(self):
        payload = b'{"test": "data"}'
        signature = _make_signature(payload)
        assert meta_webhook.verify_signature(payload, signature, "") is False

POST flow tests:

class TestNewMetaCloudApiMessage:
    def _post(self, payload_dict, app_secret=APP_SECRET):
        factory = RequestFactory()
        body = json.dumps(payload_dict).encode()
        signature = _make_signature(body, app_secret)
        request = factory.post("/", data=body, content_type="application/json",
                               HTTP_X_HUB_SIGNATURE_256=signature)
        return MetaCloudAPIWebhookView.as_view()(request)

    @patch("apps.channels.tasks.handle_meta_cloud_api_message.delay")
    def test_valid_message_returns_200(self, mock_delay, meta_cloud_api_channel):
        response = self._post(_meta_webhook_payload())
        assert response.status_code == 200
        mock_delay.assert_called_once()

    def test_invalid_signature_returns_200(self, meta_cloud_api_channel):
        """Invalid signature returns 200 to prevent Meta from retrying."""
        factory = RequestFactory()
        body = json.dumps(_meta_webhook_payload()).encode()
        request = factory.post("/", data=body, content_type="application/json",
                               HTTP_X_HUB_SIGNATURE_256="sha256=invalid")
        response = MetaCloudAPIWebhookView.as_view()(request)
        assert response.status_code == 200

Phone number ID resolution tests (5 parametrized cases):

@pytest.mark.parametrize(
    ("api_data", "lookup_number", "expected_id"),
    [
        pytest.param(
            [{"id": "111", "display_phone_number": "+1 (212) 555-2368"},
             {"id": "222", "display_phone_number": "+27 81 234 5678"}],
            "+12125552368", "111", id="formatted_number",
        ),
        pytest.param(
            [{"id": "333", "display_phone_number": "+27812345678"}],
            "+27812345678", "333", id="e164_number",
        ),
        pytest.param(
            [{"id": "111", "display_phone_number": "+1 212 555 2368"}],
            "+27812345678", None, id="no_match",
        ),
        pytest.param([], "+12125552368", None, id="empty_response"),
        pytest.param(
            [{"id": "111", "display_phone_number": "not-a-number"},
             {"id": "222", "display_phone_number": "+27 81 234 5678"}],
            "+27812345678", "222", id="unparseable_number_skipped",
        ),
    ],
)
@patch("apps.service_providers.messaging_service.httpx.get")
def test_meta_cloud_api_get_phone_number_id(mock_get, meta_cloud_api_service, api_data, lookup_number, expected_id):
    mock_get.return_value = _mock_phone_numbers_response(api_data)
    assert meta_cloud_api_service._fetch_phone_number_id(lookup_number) == expected_id

Form tests:

@patch("apps.channels.forms.ExtraFormBase.messaging_provider", new_callable=PropertyMock)
@patch("apps.service_providers.messaging_service.httpx.get")
def test_whatsapp_form_meta_cloud_api_resolves_phone_number_id(mock_httpx_get, messaging_provider, experiment):
    mock_httpx_get.return_value = httpx.Response(
        200, json={"data": [{"id": "12345", "display_phone_number": "+1 (212) 555-2368"}]},
        request=httpx.Request("GET", "https://test"),
    )
    provider = MessagingProviderFactory.create(
        type=MessagingProviderType.meta_cloud_api,
        config={"access_token": "test_token", "business_id": "biz_123"},
    )
    messaging_provider.return_value = provider
    form = WhatsappChannelForm(experiment=experiment, data={"number": "+12125552368", "messaging_provider": provider.id})
    assert form.is_valid()
    assert form.cleaned_data["phone_number_id"] == "12345"

def test_whatsapp_form_meta_cloud_api_rejects_unknown_number(...):
    # ... (empty response from API)
    assert not form.is_valid()
    assert "was not found in the WhatsApp Business Account" in form.errors["number"][0]

What to notice:

  • Coverage is thorough for phase 1 — security paths, happy paths, and edge cases all covered.
  • Tests verify invalid/missing signatures return 200 — matching Meta best practice.
  • Also fixes a flaky test in test_chatbot_tables.py (clearing cached team slugs).

Potential Concerns

  • is_valid_number side-effect coupling — The validation method caches phone_number_id as a side effect. Could be clearer as a method that returns the ID directly.
  • No pagination for phone number listing_fetch_phone_number_id fetches all numbers without handling pagination. May fail for large business accounts.
  • Phase 2 gapsend_voice_message and send_file_to_user not implemented. Calling them on a Meta channel would hit the base class.
  • Single channel per phone_number_id — The webhook view uses .first() when looking up channels. If multiple channels share the same phone_number_id, only the first will receive messages.

SmittieC and others added 10 commits March 5, 2026 10:26
Adds a new messaging provider type for integrating with the Meta Cloud
API (WhatsApp Business Platform) directly, as an alternative to
Twilio/Turn.io.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a Meta Cloud API provider is selected, the WhatsApp channel form
now calls the Phone Number Management API to resolve the user-provided
phone number to its phone number ID and stores it in extra_data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…annels

Adds a from_identifier property to WhatsappChannel that returns the
phone_number_id from extra_data when present (Meta Cloud API), falling
back to the phone number for Twilio/Turn.io providers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add MetaCloudAPIMessage dataclass for parsing Meta webhook payloads
- Add handle_meta_cloud_api_message Celery task
- Implement new_meta_cloud_api_message view with webhook verification
  and message dispatching by phone_number_id
- Add verify_token field to MetaCloudAPIMessagingConfigForm
- Set webhook_url for meta_cloud_api channels
- Remove stub handle_whatsapp_message task and hardcoded constants
- Update Meta API base URL to v25.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add app_secret field to MetaCloudAPIMessagingConfigForm and service
- Obfuscate access_token, app_secret, and verify_token in the form
- Verify HMAC-SHA256 signature on POST requests before processing
- Tries each unique app_secret from configured Meta Cloud API providers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract all message-bearing values upfront, use the first phone_number_id
to look up the channel and its app_secret, verify the signature once for
the entire payload, then dispatch tasks for each value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The phone_number_id is already passed separately to the task handler
for channel lookup, so it doesn't need to be on the message dataclass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add JSONDecodeError handling for malformed payloads
- Verify X-Hub-Signature-256 before dispatching tasks
- Extract MetaCloudAPIWebhook helper class from view
- Unify MetaCloudAPIMessage as alias for TurnWhatsappMessage
- Add select_related("messaging_provider") to get_experiment_channel
- Add webhook view tests (signature, verification, POST flow)
- Fix send_voice_to_user docstring referencing Twilio
- Add challenge None check in verify_webhook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… ExperimentChannel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@SmittieC
Copy link
Contributor Author

SmittieC commented Mar 5, 2026

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

✅ Actions performed

Full review triggered.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

📝 Walkthrough

Walkthrough

This pull request introduces comprehensive Meta Cloud API (WhatsApp) support to the system. It adds a new messaging provider type with corresponding form configuration, webhook verification and message handling, an HTTP service for API interactions, async task processing, and refactors the WhatsappChannel to support phone number ID resolution. The implementation includes database migrations, security features for webhook signature validation, and extensive test coverage across multiple layers.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main objective: adding Meta Cloud API WhatsApp integration support as a new messaging provider.
Description check ✅ Passed The pull request description is comprehensive, well-structured, and includes all required sections from the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
apps/channels/tests/test_meta_cloud_api_webhook.py (1)

142-193: Consider adding multi-channel integration test for signature verification routing.

While the current tests validate signature verification logic in isolation, there's no integration test confirming that when multiple Meta Cloud API channels exist with different app_secret values, the system correctly routes each webhook to the right channel's secret for verification. The _post() and _meta_webhook_payload() helpers already support this—create a second channel with a different secret and phone_number_id, then verify that a webhook signed for that second channel is accepted and dispatched correctly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/channels/tests/test_meta_cloud_api_webhook.py` around lines 142 - 193,
Add an integration test that creates a second Meta Cloud API channel with a
different app_secret and phone_number_id, then post a webhook signed with that
second channel's secret and assert routing/verification works: inside
TestNewMetaCloudApiMessage (use _post, _make_signature, and
_meta_webhook_payload helpers) create the second channel, patch
apps.channels.tasks.handle_meta_cloud_api_message.delay, call self._post with
the second channel's app_secret, assert response.status_code == 200 and
mock_delay.assert_called_once_with(phone_number_id=<second id>,
message_data=_meta_webhook_payload()["entry"][0]["changes"][0]["value"]); ensure
this verifies the webhook is accepted and dispatched to the correct channel via
new_meta_cloud_api_message.
apps/channels/tests/test_forms.py (1)

89-90: Move httpx to module-level imports.

Both new tests use local httpx imports without a circular-import/startup justification. Move them to the top-level import section for consistency with project standards.

♻️ Proposed cleanup
 from unittest.mock import Mock, PropertyMock, patch
 
+import httpx
 import pytest
 from django.forms.widgets import HiddenInput, Select
@@
 def test_whatsapp_form_meta_cloud_api_resolves_phone_number_id(mock_httpx_get, messaging_provider, experiment):
     """Test that the phone number ID is fetched from Meta API and stored in extra_data"""
-    import httpx
-
@@
 def test_whatsapp_form_meta_cloud_api_rejects_unknown_number(mock_httpx_get, messaging_provider, experiment):
     """Test that form validation fails when the phone number is not found in the Meta Business Account"""
-    import httpx
-

As per coding guidelines, **/*.py: Don't use local imports for any reason other than to avoid circular imports or as a means to reduce startup time.

Also applies to: 132-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/channels/tests/test_forms.py` around lines 89 - 90, Move the local
"httpx" imports to the module-level import block: add "import httpx" with the
other top-level imports and remove the two in-function/local imports (the ones
currently added near the two tests). Update any tests referencing those local
imports to use the module-level name; this keeps imports consistent and avoids
unnecessary local imports.
apps/channels/views.py (1)

449-456: Consider indexing extra_data->phone_number_id for webhook lookup throughput.

This endpoint does a per-request JSON key lookup on ExperimentChannel.extra_data. At scale, add a DB index (e.g., functional index on the JSON key) to avoid table scans on inbound webhook traffic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/channels/views.py` around lines 449 - 456, Add a DB index on the JSON
key used for webhook lookups to avoid table scans: create a new Django migration
that adds an Index on ExperimentChannel.extra_data for the "phone_number_id" key
(use KeyTextTransform('phone_number_id', 'extra_data') in the Index expression)
and apply it; update the ExperimentChannel model Meta.indexes to include
Index(KeyTextTransform('phone_number_id', 'extra_data'),
name='experimentchannel_phone_number_id_idx') so the migration can be generated
and run.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/channels/forms.py`:
- Around line 238-246: When calling service.get_phone_number_id(number) inside
the branch where self.messaging_provider.type ==
MessagingProviderType.meta_cloud_api, wrap the call in a try/except to catch
network/HTTP/timeout errors (e.g., any exception from the service client) and
convert them into a forms.ValidationError with a user-facing message (include
the original error detail for debugging if helpful), otherwise continue to check
the falsy phone_number_id and set self._phone_number_id when present; reference
the get_phone_number_id call and the self._phone_number_id assignment to locate
where to add the try/except and the forms.ValidationError raise.

In `@apps/channels/meta_webhook.py`:
- Around line 17-18: The current conditional in meta_webhook.py appends any
payload with a "messages" key even when it's empty; update the filter used
before values.append(value) so it checks that value.get("messages") is
truthy/non-empty in addition to value.get("metadata", {}).get("phone_number_id")
— e.g., require value.get("messages") and value.get("messages") != [] or
len(value.get("messages", [])) > 0 — so only payloads with actual messages are
enqueued.

In `@apps/channels/views.py`:
- Around line 458-459: The log call exposes a raw provider identifier
(first_phone_number_id); replace it with a non-reversible or partially masked
representation before logging. Locate the log.info call that references
first_phone_number_id in apps/channels/views.py and change it to log a masked
value (e.g., only last 4 chars prefixed with stars) or a hashed representation
(e.g., SHA-256 of first_phone_number_id) so the original id cannot be recovered;
ensure you compute this masked_or_hashed value in the same scope and log that
variable instead of first_phone_number_id.

In `@apps/service_providers/messaging_service.py`:
- Around line 363-364: MetaCloudAPIService makes outbound httpx requests without
timeouts (e.g., the httpx.get call in the method that assigns response =
httpx.get(url, headers=self._headers, params={"fields":
"id,display_phone_number"}) and the other httpx calls in the class), which can
hang workers; add explicit timeouts to these calls by passing a timeout
parameter (or a configurable instance attribute like self._timeout) to
httpx.get/httpx.post so each request includes a sensible timeout (e.g.,
connect/read limits), and update any related tests or callers to use the new
configurable timeout if introduced.

---

Nitpick comments:
In `@apps/channels/tests/test_forms.py`:
- Around line 89-90: Move the local "httpx" imports to the module-level import
block: add "import httpx" with the other top-level imports and remove the two
in-function/local imports (the ones currently added near the two tests). Update
any tests referencing those local imports to use the module-level name; this
keeps imports consistent and avoids unnecessary local imports.

In `@apps/channels/tests/test_meta_cloud_api_webhook.py`:
- Around line 142-193: Add an integration test that creates a second Meta Cloud
API channel with a different app_secret and phone_number_id, then post a webhook
signed with that second channel's secret and assert routing/verification works:
inside TestNewMetaCloudApiMessage (use _post, _make_signature, and
_meta_webhook_payload helpers) create the second channel, patch
apps.channels.tasks.handle_meta_cloud_api_message.delay, call self._post with
the second channel's app_secret, assert response.status_code == 200 and
mock_delay.assert_called_once_with(phone_number_id=<second id>,
message_data=_meta_webhook_payload()["entry"][0]["changes"][0]["value"]); ensure
this verifies the webhook is accepted and dispatched to the correct channel via
new_meta_cloud_api_message.

In `@apps/channels/views.py`:
- Around line 449-456: Add a DB index on the JSON key used for webhook lookups
to avoid table scans: create a new Django migration that adds an Index on
ExperimentChannel.extra_data for the "phone_number_id" key (use
KeyTextTransform('phone_number_id', 'extra_data') in the Index expression) and
apply it; update the ExperimentChannel model Meta.indexes to include
Index(KeyTextTransform('phone_number_id', 'extra_data'),
name='experimentchannel_phone_number_id_idx') so the migration can be generated
and run.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c71a8005-8b2b-4beb-b303-20b42081ade9

📥 Commits

Reviewing files that changed from the base of the PR and between a582937 and 35f7005.

📒 Files selected for processing (15)
  • apps/channels/datamodels.py
  • apps/channels/forms.py
  • apps/channels/meta_webhook.py
  • apps/channels/models.py
  • apps/channels/tasks.py
  • apps/channels/tests/test_forms.py
  • apps/channels/tests/test_meta_cloud_api_webhook.py
  • apps/channels/urls.py
  • apps/channels/views.py
  • apps/chat/channels.py
  • apps/service_providers/forms.py
  • apps/service_providers/messaging_service.py
  • apps/service_providers/migrations/0043_alter_messagingprovider_type.py
  • apps/service_providers/models.py
  • apps/service_providers/tests/test_messaging_providers.py

… char limit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

📝 Walkthrough

Walkthrough

This pull request introduces Meta Cloud API (WhatsApp) as a new messaging provider. The implementation adds a MetaCloudAPIService class for API communication, a MetaCloudAPIWebhook handler for webhook verification and message extraction, and integrates these components through existing channel forms, models, views, and task handlers. A new WhatsappChannelForm.post_save hook persists phone_number_id metadata, while a new view endpoint accepts incoming messages via POST. The changes span models, services, forms, views, tasks, and corresponding test coverage without modifying existing provider functionality.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding Meta Cloud API as a new WhatsApp messaging provider, which is the primary objective of the PR.
Description check ✅ Passed The PR description is comprehensive and well-structured, following the template with complete sections covering technical details, migrations, demo setup, and changelog requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (4)
apps/channels/forms.py (1)

238-246: ⚠️ Potential issue | 🟠 Major

Convert Meta lookup failures into ValidationError.

If get_phone_number_id raises (HTTP/network/timeout), this path returns a server error instead of a form error.

🛠️ Proposed fix
             if self.messaging_provider.type == MessagingProviderType.meta_cloud_api:
-                phone_number_id = service.get_phone_number_id(number)
+                try:
+                    phone_number_id = service.get_phone_number_id(number)
+                except Exception:
+                    logger.exception("Failed to resolve Meta Cloud API phone_number_id")
+                    raise forms.ValidationError(
+                        "Could not validate this number with Meta right now. Please try again."
+                    ) from None
                 if not phone_number_id:
                     raise forms.ValidationError(
                         f"{number} was not found in the WhatsApp Business Account. "
                         "Please verify the number is registered with your business."
                     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/channels/forms.py` around lines 238 - 246, Wrap the call to
service.get_phone_number_id inside the Meta branch (where
self.messaging_provider.type == MessagingProviderType.meta_cloud_api) in a
try/except that catches network/HTTP/timeout errors and converts them into a
forms.ValidationError (instead of letting them bubble up); only assign
self._phone_number_id if the call succeeds, and include a clear validation
message (e.g., number not found or lookup failed) when raising the
ValidationError.
apps/service_providers/messaging_service.py (1)

363-364: ⚠️ Potential issue | 🟠 Major

Add explicit timeouts to Meta API HTTP calls.

Both Meta outbound requests still omit timeout, so transient network stalls can hang workers.

🛠️ Proposed fix
 class MetaCloudAPIService(MessagingService):
@@
     META_API_BASE_URL: ClassVar[str] = "https://graph.facebook.com/v25.0"
+    REQUEST_TIMEOUT: ClassVar[float] = 10.0
@@
-        response = httpx.get(url, headers=self._headers, params={"fields": "id,display_phone_number"})
+        response = httpx.get(
+            url,
+            headers=self._headers,
+            params={"fields": "id,display_phone_number"},
+            timeout=self.REQUEST_TIMEOUT,
+        )
@@
-        response = httpx.post(url, headers=self._headers, json=data)
+        response = httpx.post(url, headers=self._headers, json=data, timeout=self.REQUEST_TIMEOUT)
#!/bin/bash
# Verify MetaCloudAPIService HTTP calls and whether timeout is set.
rg -nP 'class MetaCloudAPIService|httpx\.(get|post)\(' apps/service_providers/messaging_service.py -C3

Also applies to: 384-385

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/service_providers/messaging_service.py` around lines 363 - 364, The
MetaCloudAPIService HTTP calls (the httpx.get and httpx.post usages in the
MetaCloudAPIService class) lack explicit timeouts and can hang; update both
calls (the GET that sets params {"fields": "id,display_phone_number"} and the
other POST/GET around the 384-385 area) to include a sensible timeout parameter
(e.g., timeout=self._timeout or timeout=10) so requests never block
indefinitely, and keep response.raise_for_status() as-is; if the class has no
_timeout attribute, add one (or use a module-level constant) and use it for both
httpx calls for consistency.
apps/channels/meta_webhook.py (1)

17-18: ⚠️ Potential issue | 🟡 Minor

Filter out empty messages payloads before enqueue path.

Line 17 currently accepts any payload that merely contains a messages key, including empty arrays.

✅ Proposed fix
-                if "messages" in value and value.get("metadata", {}).get("phone_number_id"):
+                messages = value.get("messages") or []
+                if messages and value.get("metadata", {}).get("phone_number_id"):
                     values.append(value)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/channels/meta_webhook.py` around lines 17 - 18, The current condition
around values.append(value) accepts payloads with a "messages" key even when
it's an empty list; update the conditional that includes values.append(value)
(the if using "messages" in value and value.get("metadata",
{}).get("phone_number_id")) to explicitly check that value.get("messages") is a
non-empty list (e.g., truthy and isinstance(list) and len(...) > 0) or otherwise
contains at least one non-empty message before appending, so empty messages
payloads are filtered out prior to enqueue.
apps/channels/views.py (1)

458-458: ⚠️ Potential issue | 🟠 Major

Remove clear-text logging of phone_number_id.

Line 458 logs a sensitive identifier directly.

🔒 Proposed fix
-        log.info("No channel found for phone_number_id: %s", first_phone_number_id)
+        log.info("No channel found for incoming Meta Cloud API webhook payload")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/channels/views.py` at line 458, The log at the log.info call that prints
first_phone_number_id in apps/channels/views.py should not emit the raw
identifier; instead redact or pseudonymize it before logging (e.g., replace with
a fixed placeholder, masked string, or a short hash/fingerprint) so the log only
indicates "no channel found" plus a non-reversible token; update the log.info
invocation that references first_phone_number_id to use the redacted/hashed
value (or omit the ID entirely) and, if you add a helper, implement it near the
view (e.g., a redact_or_hash utility) and use that helper when logging.
🧹 Nitpick comments (2)
apps/channels/tests/test_forms.py (1)

89-89: Move httpx import to module scope.

Line 89 and Line 132 use local imports without a circular-import/startup-time reason.

♻️ Proposed fix
 from unittest.mock import Mock, PropertyMock, patch
 
+import httpx
 import pytest
 from django.forms.widgets import HiddenInput, Select
@@
 def test_whatsapp_form_meta_cloud_api_resolves_phone_number_id(mock_httpx_get, messaging_provider, experiment):
     """Test that the phone number ID is fetched from Meta API and stored in extra_data"""
-    import httpx
-
@@
 def test_whatsapp_form_meta_cloud_api_rejects_unknown_number(mock_httpx_get, messaging_provider, experiment):
     """Test that form validation fails when the phone number is not found in the Meta Business Account"""
-    import httpx
-
As per coding guidelines `**/*.py`: Don't use local imports for any reason other than to avoid circular imports or as a means to reduce startup time.

Also applies to: 132-132

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/channels/tests/test_forms.py` at line 89, Two local imports of the httpx
package were added inside test functions (at the locations shown) even though
there is no circular-import or startup-time reason; move the httpx import to
module scope at the top of the file and remove the local imports so tests
reference the module-level httpx import (update the test functions that
currently import httpx to use the top-level import).
apps/channels/tests/test_meta_cloud_api_webhook.py (1)

157-168: Add a regression test for mixed phone_number_id batches.

Current dispatch coverage is single-value only. Please add a case where the first value is unknown and a later value is known, so ordering bugs don’t silently drop valid messages.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/channels/tests/test_meta_cloud_api_webhook.py` around lines 157 - 168,
Add a new test that posts a payload containing a batch with mixed
phone_number_id values (first an unknown id, then a known id "12345") to
exercise ordering; use the existing helper _meta_webhook_payload() or construct
a payload with two entries/changes where
entry[0].changes[0].value.phone_number_id is unknown and
entry[1].changes[0].value.phone_number_id == "12345", call self._post(...) in
the test (decorated with
`@patch`("apps.channels.tasks.handle_meta_cloud_api_message.delay")), then assert
that handle_meta_cloud_api_message.delay was called exactly once with
phone_number_id="12345" and message_data equal to the corresponding value from
the payload, and do not expect a call for the unknown id.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/channels/tasks.py`:
- Line 203: The log statement using log.info that prints the raw phone_number_id
should be changed to avoid emitting sensitive identifiers; update the log call
in the tasks module (the log.info referencing phone_number_id) to either remove
the identifier entirely or log a redacted/masked form (e.g., hash or show only
last 4 characters) or replace with a fixed placeholder like
"<redacted_phone_number_id>" so no raw provider identifier is written to logs.

In `@apps/channels/views.py`:
- Around line 447-456: The code currently uses first_phone_number_id from
message_values[0] to find an ExperimentChannel, which fails for batched payloads
where other messages may have different phone_number_ids; instead, collect all
phone_number_ids from message_values, query ExperimentChannel with
extra_data__phone_number_id__in=<collected_ids> and
messaging_provider__type=MessagingProviderType.meta_cloud_api, then map returned
channels by their extra_data phone_number_id so each incoming message can be
matched to its correct channel (use the message_values list and the channel map
rather than the single variable first_phone_number_id); update the logic around
ExperimentChannel.objects.filter(...).first() to use the set-based query and
per-message lookup.

In `@apps/chat/channels.py`:
- Around line 1157-1162: The from_identifier property currently falls back to
extra_data["number"] when "phone_number_id" is missing, which causes invalid
Meta Graph API paths; update the from_identifier property (on the class that
accesses experiment_channel.extra_data) to check the channel/provider type
(e.g., Meta) and if the provider is Meta, require "phone_number_id" to exist —
raise a clear exception when it's missing instead of returning the plain
"number"; for non-Meta providers continue to return extra_data["number"] as
before.

---

Duplicate comments:
In `@apps/channels/forms.py`:
- Around line 238-246: Wrap the call to service.get_phone_number_id inside the
Meta branch (where self.messaging_provider.type ==
MessagingProviderType.meta_cloud_api) in a try/except that catches
network/HTTP/timeout errors and converts them into a forms.ValidationError
(instead of letting them bubble up); only assign self._phone_number_id if the
call succeeds, and include a clear validation message (e.g., number not found or
lookup failed) when raising the ValidationError.

In `@apps/channels/meta_webhook.py`:
- Around line 17-18: The current condition around values.append(value) accepts
payloads with a "messages" key even when it's an empty list; update the
conditional that includes values.append(value) (the if using "messages" in value
and value.get("metadata", {}).get("phone_number_id")) to explicitly check that
value.get("messages") is a non-empty list (e.g., truthy and isinstance(list) and
len(...) > 0) or otherwise contains at least one non-empty message before
appending, so empty messages payloads are filtered out prior to enqueue.

In `@apps/channels/views.py`:
- Line 458: The log at the log.info call that prints first_phone_number_id in
apps/channels/views.py should not emit the raw identifier; instead redact or
pseudonymize it before logging (e.g., replace with a fixed placeholder, masked
string, or a short hash/fingerprint) so the log only indicates "no channel
found" plus a non-reversible token; update the log.info invocation that
references first_phone_number_id to use the redacted/hashed value (or omit the
ID entirely) and, if you add a helper, implement it near the view (e.g., a
redact_or_hash utility) and use that helper when logging.

In `@apps/service_providers/messaging_service.py`:
- Around line 363-364: The MetaCloudAPIService HTTP calls (the httpx.get and
httpx.post usages in the MetaCloudAPIService class) lack explicit timeouts and
can hang; update both calls (the GET that sets params {"fields":
"id,display_phone_number"} and the other POST/GET around the 384-385 area) to
include a sensible timeout parameter (e.g., timeout=self._timeout or timeout=10)
so requests never block indefinitely, and keep response.raise_for_status()
as-is; if the class has no _timeout attribute, add one (or use a module-level
constant) and use it for both httpx calls for consistency.

---

Nitpick comments:
In `@apps/channels/tests/test_forms.py`:
- Line 89: Two local imports of the httpx package were added inside test
functions (at the locations shown) even though there is no circular-import or
startup-time reason; move the httpx import to module scope at the top of the
file and remove the local imports so tests reference the module-level httpx
import (update the test functions that currently import httpx to use the
top-level import).

In `@apps/channels/tests/test_meta_cloud_api_webhook.py`:
- Around line 157-168: Add a new test that posts a payload containing a batch
with mixed phone_number_id values (first an unknown id, then a known id "12345")
to exercise ordering; use the existing helper _meta_webhook_payload() or
construct a payload with two entries/changes where
entry[0].changes[0].value.phone_number_id is unknown and
entry[1].changes[0].value.phone_number_id == "12345", call self._post(...) in
the test (decorated with
`@patch`("apps.channels.tasks.handle_meta_cloud_api_message.delay")), then assert
that handle_meta_cloud_api_message.delay was called exactly once with
phone_number_id="12345" and message_data equal to the corresponding value from
the payload, and do not expect a call for the unknown id.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f3640f13-cee7-4cd4-ac2b-f1d5ba65a5a5

📥 Commits

Reviewing files that changed from the base of the PR and between a582937 and 35f7005.

📒 Files selected for processing (15)
  • apps/channels/datamodels.py
  • apps/channels/forms.py
  • apps/channels/meta_webhook.py
  • apps/channels/models.py
  • apps/channels/tasks.py
  • apps/channels/tests/test_forms.py
  • apps/channels/tests/test_meta_cloud_api_webhook.py
  • apps/channels/urls.py
  • apps/channels/views.py
  • apps/chat/channels.py
  • apps/service_providers/forms.py
  • apps/service_providers/messaging_service.py
  • apps/service_providers/migrations/0043_alter_messagingprovider_type.py
  • apps/service_providers/models.py
  • apps/service_providers/tests/test_messaging_providers.py

SmittieC and others added 5 commits March 5, 2026 17:01
…ation

Add extra_data JSONField to MessagingProvider to store non-encrypted
queryable data. For Meta Cloud API providers, a SHA-256 hash of the
verify_token is stored on save, allowing verify_webhook to filter
directly in the DB instead of iterating and decrypting all providers.

Also fixes test expecting 400 for invalid signature when the view
intentionally returns 200 to prevent Meta retries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Convert MetaCloudAPIWebhook static methods to module-level functions
- Add view-level GET verification tests
- Add test for missing X-Hub-Signature-256 header
- Use Django's constant_time_compare instead of hmac.compare_digest
- Add comments explaining signature verification placement and
  multi-phone-number payload safety

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Filter out empty messages payloads in extract_message_values
- Handle Meta API lookup failures with user-friendly validation error
- Add explicit 30s timeouts for outbound Meta API httpx calls
- Redact phone_number_id from log messages
- Fail fast in WhatsappChannel.from_identifier when phone_number_id is missing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
name="new_sureadhere_message",
),
path("whatsapp/turn/<uuid:experiment_id>/incoming_message", views.new_turn_message, name="new_turn_message"),
path("whatsapp/meta/incoming_message", views.new_meta_cloud_api_message, name="new_meta_cloud_api_message"),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have done whatsapp/meta/incoming_message/<uiud:channel_external_id> which will help us find the channel immediately, but since multiple numbers can be linked to a business account, we probably want to keep support for multiple numbers

@codecov-commenter
Copy link

codecov-commenter commented Mar 6, 2026

Codecov Report

❌ Patch coverage is 90.08264% with 36 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
apps/channels/tasks.py 33.33% 8 Missing ⚠️
apps/channels/views.py 83.67% 8 Missing ⚠️
apps/service_providers/messaging_service.py 86.66% 6 Missing ⚠️
apps/chat/channels.py 60.00% 4 Missing ⚠️
apps/service_providers/forms.py 66.66% 4 Missing ⚠️
apps/channels/forms.py 84.21% 3 Missing ⚠️
apps/channels/models.py 0.00% 2 Missing ⚠️
apps/channels/meta_webhook.py 96.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

SmittieC and others added 5 commits March 6, 2026 08:55
Documents each MetaCloudAPIMessagingConfigForm parameter (verify_token,
app_secret, business_id, access_token), explaining when and how each is
used in the webhook verification, signature checking, phone number
resolution, and message sending flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Convert new_meta_cloud_api_message function into MetaCloudAPIWebhookView
CBV, splitting GET (webhook verification) and POST (message handling)
into separate methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@SmittieC SmittieC marked this pull request as ready for review March 6, 2026 07:35
@SmittieC SmittieC requested a review from snopoke March 6, 2026 07:36
type = models.CharField(max_length=255, choices=MessagingProviderType.choices)
name = models.CharField(max_length=255)
config = encrypt(models.JSONField(default=dict))
extra_data = models.JSONField(default=dict, blank=True)
Copy link
Contributor Author

@SmittieC SmittieC Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See https://github.com/dimagi/open-chat-studio/blob/e09de8e6ae433ccc03cc4da9b4d4411922c01808/docs/developer_guides/meta_cloud_api_integration.md#verify_token--webhook-verification on how this field is used.

TLDR:
The verify token is stored in an encrypted column, but since we cannot filter encrypted columns, we take the hash of the secret and add it in this column instead, making it filterable.

This was done as an optimization step where we have to find the messaging provider based on the verify_token (sent in a header). Instead of iterating over all messaging providers to check if there is one with this token, we now filter using the hash value.

number = phonenumbers.format_number(number_obj, phonenumbers.PhoneNumberFormat.E164)
service = self.messaging_provider.get_messaging_service()
if not service.is_valid_number(number):
if self.messaging_provider.type == MessagingProviderType.meta_cloud_api:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't this be moved into MessagingService.is_valid_number

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a5ca39. Added is_valid_number to MetaCloudAPIService which wraps the phone number ID lookup. The form's clean method now calls service.is_valid_number(number) uniformly for all providers — no provider-type check needed for validation.

f"{number} was not found in the WhatsApp Business Account. "
"Please verify the number is registered with your business."
)
self._phone_number_id = phone_number_id
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels hacky - what about doing basic phone number validation here (make sure it's a correctly formatted phone number) and then moving the service level check to the clean method and putting the phone_number_id into form.cleaned_data.

I think that would follow normal Django patterns better and you won't need to rely on a temporary field on the form.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a5ca39. clean_number now only does phone number format validation. The service-level check (is_valid_number) moved to clean, and phone_number_id goes into cleaned_data directly — no more temp attribute or post_save override.

Comment on lines +197 to +204
experiment_channel = get_experiment_channel(
ChannelPlatform.WHATSAPP,
extra_data__phone_number_id=phone_number_id,
messaging_provider__type=MessagingProviderType.meta_cloud_api,
)
if not experiment_channel:
log.info("No experiment channel found for incoming Meta Cloud API message")
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to handle this in the view and pass through the channel ID in the task.

This also allows passing through the team slug in the task which is useful for debugging.

It also means we don't ever have to fire off a task if there is no channel.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a5ca39. The view now passes channel_id and team_slug to the task instead of phone_number_id. The task looks up by ID directly — no redundant filter query, and tasks are never fired for missing channels.

Co-authored-by: Simon Kelly <skelly@dimagi.com>
SmittieC and others added 3 commits March 9, 2026 11:35
- Refactor WhatsappChannelForm: clean_number does format validation only,
  clean handles service-level validation via is_valid_number and stores
  phone_number_id in cleaned_data for Meta channels
- Add is_valid_number and get_phone_number_id to MetaCloudAPIService so
  the form delegates validation to the service layer
- Pass channel_id and team_slug from view to task instead of
  phone_number_id, avoiding redundant DB lookup in the task
- Add comment explaining the whatsapp_business_account object check with
  link to Meta docs
- Add debug logging to webhook GET verification handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Debug-level logs for early exits (invalid JSON, non-whatsapp object,
no messages, missing fields), info for missing channel, warning for
signature verification failure, and debug for successful dispatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The test failed intermittently because get_slug_for_team() caches slugs
in Django's cache, and when test transactions roll back, DB IDs can be
reused with different slugs while the cache retains stale values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A single Meta webhook payload can contain messages for multiple phone
numbers (e.g. a WhatsApp Business Account with several registered
numbers). Previously all messages were dispatched to the channel of the
first phone number, silently misrouting any subsequent messages.

Now all unique phone_number_ids in the payload are fetched in a single
bulk query and each message is routed to its correct channel. Messages
with no matching channel are logged and skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
.select_related("experiment", "team", "messaging_provider")
.first()
)
if not channel:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this validation logic doesn't match with logic later that supports multiple phone numbers

SmittieC and others added 4 commits March 13, 2026 13:16
An attacker with a legitimate channel could forge messages for a victim's
phone number by including it in a payload signed with their own app_secret.
Fix _payload_has_valid_signature to reject any payload whose channels span
more than one messaging provider (distinct app_secret). Also adds a test
covering the cross-provider forgery scenario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants